//
//  PaperPainter.swift
//  Neuridion Mobile
//
//  Draws a `NeuridionPaper` onto A4, in two passes.
//
//  --- Why two passes ----------------------------------------------------
//
//  `@Pages` is not known until everything has been set, and the carry-over
//  under a table needs the same information — the subtotal at the break, and
//  again at the top of the page after it. So the sheet is **laid out first
//  into pages of pieces**, and only then drawn, with the page count and the
//  carry-overs filled in.
//
//  It was weighed and kept. "Seite 1 von 3" and the carry-over are commercial
//  standard, and without them a multi-page invoice looks homemade. The cost is
//  this file being a layout engine rather than a drawing routine, and it is
//  paid once.
//
//  --- Why it is separate from `PaperMaker` ------------------------------
//
//  `PaperMaker` turns *a screen* into a paper, and it stays exactly as it is:
//  for a payslip that is the right answer and it needs no template. This is
//  the other supplier for the same A4 — a designed sheet, with bands, blocks
//  and a repetition. Two entrances, one kind of paper.
//
//  --- What it does not know --------------------------------------------
//
//  Nothing about `PreviewRuntime`. Everything that has to be looked up
//  arrives as `PaperSource`: the rows of a table filtered by a key, a name
//  resolved outside the record, and the type of a column so a cell can be
//  drawn in its own spelling. That is what lets the same painter run in the
//  designer, on the phone and in an exported app.
//

import Foundation
import CoreGraphics
import CoreText
import CoreImage

/// A record being printed: column name to value, exactly as a row is held
/// everywhere else here.
typealias PaperRecord = [String: String]

/// A record in view while a sheet is laid out, and the table it came from.
///
/// The table is not decoration: it is what lets a chip outside a table be
/// spelled the way the same column is spelled inside one. Without it a date
/// prints twice on the same sheet in two different spellings.
struct PaperScopeRow {
    var table: String
    var row: PaperRecord
}

/// What the painter asks of whoever is printing.
struct PaperSource {
    /// The rows of `table` whose `keyColumn` equals `keyValue`. An empty key
    /// column means the whole table — the report case, and the same "no key
    /// means everything" rule `Add Up` has.
    var rows: (_ table: String, _ keyColumn: String, _ keyValue: String) -> [PaperRecord]
    /// A name that is not a column of the record: a control of the screen, a
    /// variable, a setting — searched in that order by the caller, which is
    /// the order `Formula` already uses.
    var lookup: (_ name: String) -> String?
    /// How a cell should be spelled. Nil draws it as it is stored.
    var type: (_ table: String, _ column: String) -> DatabaseColumn.ColumnType?
    /// The project's currency, because the business has one and the reader
    /// does not get a vote.
    var currency: String
    /// Where `assets/` is, for a logo or an imported picture.
    var assets: URL?

    init(rows: @escaping (String, String, String) -> [PaperRecord] = { _, _, _ in [] },
         lookup: @escaping (String) -> String? = { _ in nil },
         type: @escaping (String, String) -> DatabaseColumn.ColumnType? = { _, _ in nil },
         currency: String = "",
         assets: URL? = nil) {
        self.rows = rows
        self.lookup = lookup
        self.type = type
        self.currency = currency
        self.assets = assets
    }
}

enum PaperPainter {

    static let pageWidth: CGFloat = 595
    static let pageHeight: CGFloat = 842

    // MARK: - The one entrance

    /// Lays the sheet out and draws it. Nil only when a PDF context cannot be
    /// made at all.
    static func pdf(_ paper: NeuridionPaper, record: PaperRecord,
                    source: PaperSource) -> Data? {
        pdf(paper, records: [record], source: source)
    }

    /// The same sheet over many records, in **one** document.
    ///
    /// That is the serial letter, and it is not a second feature: it is the
    /// letter printed over many rows rather than one, which is the question
    /// `For Each Row` already asks. One document by default, because that is
    /// the stack somebody puts in the printer.
    ///
    /// **Each record's pages are numbered within that record** — "Seite 1 von
    /// 2" on a two-page letter, not "Seite 37 von 400". A recipient holds one
    /// letter, and a number counting the whole run would be a number about the
    /// sender's afternoon.
    static func pdf(_ paper: NeuridionPaper, records: [PaperRecord],
                    source: PaperSource) -> Data? {
        guard !records.isEmpty else { return nil }
        let style = Style(paper)
        let runs = records.map { layout(paper, record: $0, source: source, style: style) }
        return draw(runs, paper: paper, style: style)
    }

    /// The layout on its own, for tests: how many pages, and what is on each.
    /// A test that can only look at a PDF's bytes proves nothing.
    static func pageCount(_ paper: NeuridionPaper, record: PaperRecord,
                          source: PaperSource) -> Int {
        layout(paper, record: record, source: source, style: Style(paper)).count
    }

    // MARK: - The look, resolved once

    struct Style {
        let look: PaperLook
        let margin: CGFloat
        let body: CGFloat
        let heading: CGFloat
        let small: CGFloat
        let gap: CGFloat
        let leading: CGFloat
        let accent: CGColor
        let serif: Bool

        init(_ paper: NeuridionPaper) {
            look = paper.look
            let scale = paper.density.scale
            margin = paper.look.margin * (scale * 0.5 + 0.5)
            body = paper.look.bodySize * scale
            heading = paper.look.headingSize * scale
            small = max(6, paper.look.bodySize * 0.72 * scale)
            gap = 9 * scale
            leading = paper.look.bodySize * scale * 1.42
            serif = paper.look.isSerif
            accent = paper.look.usesAccent ? Style.colour(paper.accent)
                                           : CGColor(gray: 0.1, alpha: 1)
        }

        var textWidth: CGFloat { pageWidth - margin * 2 }

        /// `"3D8BFF"` to a colour. An unreadable value is black rather than a
        /// crash — the same forgiveness a cell gets.
        static func colour(_ hex: String) -> CGColor {
            var value: UInt64 = 0
            let cleaned = hex.hasPrefix("#") ? String(hex.dropFirst()) : hex
            guard cleaned.count == 6, Scanner(string: cleaned).scanHexInt64(&value) else {
                return CGColor(gray: 0.1, alpha: 1)
            }
            return CGColor(red: CGFloat((value >> 16) & 0xFF) / 255,
                           green: CGFloat((value >> 8) & 0xFF) / 255,
                           blue: CGFloat(value & 0xFF) / 255, alpha: 1)
        }
    }

    // MARK: - What a laid-out page holds

    /// One thing to draw at a place. The layout produces these; the drawing
    /// pass only paints them, which is what makes the second pass cheap.
    enum Piece {
        case text(String, x: CGFloat, y: CGFloat, size: CGFloat, bold: Bool,
                  colour: CGColor, width: CGFloat, trailing: Bool)
        case rule(y: CGFloat, from: CGFloat, to: CGFloat, thickness: CGFloat, colour: CGColor)
        case fill(CGRect, CGColor)
        case picture(CGImage, CGRect)
        case missing(String, CGRect)
        /// Filled in on the second pass, when the count is known.
        case pageNumber(x: CGFloat, y: CGFloat, size: CGFloat, colour: CGColor)
    }

    struct Page { var pieces: [Piece] = [] }

    // MARK: - Pass one: laying out

    private struct Cursor {
        var pages: [Page] = [Page()]
        var y: CGFloat
        let top: CGFloat
        let bottom: CGFloat

        mutating func add(_ piece: Piece) { pages[pages.count - 1].pieces.append(piece) }

        /// Room for `height` on this page, or a new one.
        mutating func need(_ height: CGFloat) -> Bool {
            guard y - height < bottom else { return false }
            pages.append(Page())
            y = top
            return true
        }
    }

    /// How tall the head band really is, rather than a guess.
    ///
    /// The first version reserved `body * 4.2` and drew the contact lines from
    /// the top down — so five of them ran *through* the accent bar and into the
    /// address. Nothing failed; the tests all passed and the PDF was wrong.
    /// **This is why a paper gets looked at with an eye.**
    static func headHeight(_ paper: NeuridionPaper, _ style: Style) -> CGFloat {
        guard paper.head.isOn else { return 0 }
        let left = style.body * 1.25 + style.small * 1.6
        let right = CGFloat(min(5, paper.head.contact.count)) * style.small * 1.4
        return max(left, right) + (style.look.showsAccentBar ? 8 : 4) + style.gap
    }

    private static func layout(_ paper: NeuridionPaper, record: PaperRecord,
                               source: PaperSource, style: Style) -> [Page] {
        let headHeight = self.headHeight(paper, style)
        let footHeight = paper.foot.isOn ? style.small * 5.5 : 0
        var cursor = Cursor(y: pageHeight - style.margin - headHeight,
                            top: pageHeight - style.margin - headHeight,
                            bottom: style.margin + footHeight)

        var scopes: [PaperScopeRow] = [PaperScopeRow(table: paper.tableName, row: record)]
        place(paper.blocks, &cursor, &scopes, paper: paper, source: source, style: style)
        return cursor.pages
    }

    /// Walks a list of blocks. `scopes` is the stack of records in view — the
    /// sheet's own at the bottom, a repetition's row on top. A chip is looked
    /// up from the top down, which is what makes `{Job.Titel}` inside a
    /// repetition mean the job and not the project.
    private static func place(_ blocks: [PaperBlock], _ cursor: inout Cursor,
                              _ scopes: inout [PaperScopeRow], paper: NeuridionPaper,
                              source: PaperSource, style: Style) {
        for block in blocks {
            switch block.kind {

            case .pageBreak:
                _ = cursor.need(.greatestFiniteMagnitude)

            case .spacer:
                let height = CGFloat(block.height) * paper.density.scale
                _ = cursor.need(height)
                if block.hasRule {
                    cursor.add(.rule(y: cursor.y - height / 2, from: style.margin,
                                     to: pageWidth - style.margin, thickness: 0.5,
                                     colour: CGColor(gray: 0.75, alpha: 1)))
                }
                cursor.y -= height

            case .heading:
                let text = spell(block.text, scopes, source)
                _ = cursor.need(style.heading + style.gap)
                let centred = style.look.centresHeading
                cursor.add(.text(text, x: centred ? style.margin : style.margin,
                                 y: cursor.y, size: style.heading, bold: true,
                                 colour: style.accent,
                                 width: style.textWidth, trailing: false))
                if centred {
                    // Centring is drawn as a trailing run in a half-width box
                    // rather than as a third alignment: two alignments cover
                    // every line on a sheet, and a third is a setting nobody
                    // sets on purpose.
                    cursor.pages[cursor.pages.count - 1].pieces.removeLast()
                    cursor.add(.text(text, x: style.margin, y: cursor.y,
                                     size: style.heading, bold: true, colour: style.accent,
                                     width: style.textWidth, trailing: false))
                }
                cursor.y -= style.heading + style.gap

            case .paragraph:
                let text = spell(block.text, scopes, source)
                for line in wrap(text, width: style.textWidth, size: style.body, style: style) {
                    _ = cursor.need(style.leading)
                    cursor.add(.text(line, x: style.margin, y: cursor.y, size: style.body,
                                     bold: false, colour: CGColor(gray: 0.1, alpha: 1),
                                     width: style.textWidth, trailing: false))
                    cursor.y -= style.leading
                }
                cursor.y -= style.gap * 0.6

            case .valueLine:
                _ = cursor.need(style.leading)
                cursor.add(.text(block.label, x: style.margin, y: cursor.y, size: style.body,
                                 bold: false, colour: CGColor(gray: 0.45, alpha: 1),
                                 width: style.textWidth * 0.5, trailing: false))
                cursor.add(.text(spell(block.value, scopes, source), x: style.margin,
                                 y: cursor.y, size: style.body, bold: false,
                                 colour: CGColor(gray: 0.1, alpha: 1),
                                 width: style.textWidth, trailing: true))
                cursor.y -= style.leading

            case .address:
                _ = cursor.need(style.leading * CGFloat(max(1, block.lines.count)) + style.gap)
                for line in block.lines {
                    // A line whose chips are all empty still leaves a gap.
                    // Olly's call, and the same rule as everywhere else on the
                    // sheet: what is empty is empty, and nothing shuffles up.
                    cursor.add(.text(spell(line, scopes, source), x: style.margin,
                                     y: cursor.y, size: style.body, bold: false,
                                     colour: CGColor(gray: 0.1, alpha: 1),
                                     width: style.textWidth * 0.6, trailing: false))
                    cursor.y -= style.leading
                }
                cursor.y -= style.gap

            case .sum:
                let value = tally(block, scopes: scopes, source: source, paper: paper)
                let size = block.isEmphasised ? style.body * 1.25 : style.body
                _ = cursor.need(size + style.gap)
                if block.isEmphasised {
                    cursor.y -= style.gap * 0.9
                    cursor.add(.rule(y: cursor.y + style.gap * 0.55,
                                     from: pageWidth - style.margin - 190,
                                     to: pageWidth - style.margin, thickness: 1,
                                     colour: CGColor(gray: 0.1, alpha: 1)))
                }
                cursor.add(.text(block.label, x: pageWidth - style.margin - 190, y: cursor.y,
                                 size: size, bold: block.isEmphasised,
                                 colour: CGColor(gray: 0.1, alpha: 1), width: 120,
                                 trailing: false))
                cursor.add(.text(value, x: style.margin, y: cursor.y, size: size,
                                 bold: block.isEmphasised,
                                 colour: CGColor(gray: 0.1, alpha: 1),
                                 width: style.textWidth, trailing: true))
                cursor.y -= size + style.gap

            case .tax:
                let lines = taxLines(block, scopes: scopes, source: source, paper: paper)
                for (index, line) in lines.enumerated() {
                    let last = index == lines.count - 1
                    let size = last ? style.body * 1.25 : style.body
                    _ = cursor.need(size)
                    if last {
                        // Air first, then the rule, then the line. Drawn at
                        // `cursor.y + 3` it landed *through* the rate above it
                        // and struck the figure out — a rule between two lines
                        // needs a gap of its own, not three points of hope.
                        cursor.y -= style.gap * 0.9
                        cursor.add(.rule(y: cursor.y + style.gap * 0.55,
                                         from: pageWidth - style.margin - 190,
                                         to: pageWidth - style.margin, thickness: 1,
                                         colour: CGColor(gray: 0.1, alpha: 1)))
                    }
                    cursor.add(.text(line.0, x: pageWidth - style.margin - 190, y: cursor.y,
                                     size: size, bold: last,
                                     colour: CGColor(gray: last ? 0.1 : 0.45, alpha: 1),
                                     width: 130, trailing: false))
                    cursor.add(.text(line.1, x: style.margin, y: cursor.y, size: size,
                                     bold: last, colour: CGColor(gray: 0.1, alpha: 1),
                                     width: style.textWidth, trailing: true))
                    cursor.y -= size + (last ? style.gap : 0)
                }
                cursor.y -= style.gap * 0.5

            case .image:
                placeImage(block, &cursor, scopes, source: source, style: style)

            case .tableRow:
                // Only ever drawn by its repetition, which knows the headings
                // and the widths. On its own it is a block somebody moved out
                // of one, and drawing it alone would be a row with no table.
                break

            case .repeatRows:
                placeRepetition(block, &cursor, &scopes, paper: paper,
                                source: source, style: style)
            }
        }
    }

    // MARK: - The repetition

    private static func placeRepetition(_ block: PaperBlock, _ cursor: inout Cursor,
                                        _ scopes: inout [PaperScopeRow],
                                        paper: NeuridionPaper, source: PaperSource,
                                        style: Style) {
        // **The key is compared unspelled.** A date drawn as "10. Oktober
        // 2024" is the right thing to *print* and would never match the
        // "2024-10-10" in the file — spelling belongs to display, not to
        // comparison, and confusing the two empties every repetition keyed on
        // a date.
        let key = raw(block.keyValue, scopes, source)
        let rows = Array(source.rows(block.table, block.keyColumn, key)
            .prefix(DataLimits.rowsHeld))

        if block.drawsAsTable, let row = block.children.first {
            placeTable(block, row: row, rows: rows, &cursor, source: source, style: style)
            return
        }
        for record in rows {
            scopes.append(PaperScopeRow(table: block.table, row: record))
            place(block.children, &cursor, &scopes, paper: paper, source: source, style: style)
            scopes.removeLast()
        }
    }

    /// A repetition holding one table row, drawn as a table: headings on every
    /// page, and a carry-over at the break.
    private static func placeTable(_ block: PaperBlock, row: PaperBlock,
                                   rows: [PaperRecord], _ cursor: inout Cursor,
                                   source: PaperSource, style: Style) {
        let columns = row.columns
        guard !columns.isEmpty else { return }
        let total = columns.map(\.width).reduce(0, +)
        let widths = columns.map { CGFloat($0.width / max(total, 0.0001)) * style.textWidth }

        // **A gutter between the columns, and none at the two outer edges.**
        // Without it a right-aligned heading touches the left-aligned one
        // beside it and the row reads "PositionDescription" — which is what
        // the first invoice off this painter actually said. The outer edges
        // stay flush so the amounts still end on the margin, where the eye
        // and the fold of the page both expect them.
        let gutter = max(3, style.body * 0.5)
        func slot(_ index: Int, from x: CGFloat) -> (x: CGFloat, width: CGFloat) {
            let left = x + (index == 0 ? 0 : gutter / 2)
            let right = x + widths[index] - (index == columns.count - 1 ? 0 : gutter / 2)
            return (left, max(right - left, 1))
        }
        let zebra = style.look.tableStyle == .zebra
        let ruled = style.look.tableStyle == .rules

        // The column a carry-over adds up: the last trailing one, which on
        // every invoice in the world is the amount.
        let carryIndex = columns.lastIndex(where: \.isTrailing)
        var carried = 0.0

        func heads() {
            var x = style.margin
            for (index, column) in columns.enumerated() {
                let cell = slot(index, from: x)
                cursor.add(.text(column.heading, x: cell.x, y: cursor.y, size: style.small,
                                 bold: true, colour: CGColor(gray: 0.45, alpha: 1),
                                 width: cell.width, trailing: column.isTrailing))
                x += widths[index]
            }
            cursor.y -= style.small + 3
            cursor.add(.rule(y: cursor.y + 2, from: style.margin,
                             to: pageWidth - style.margin, thickness: 0.7,
                             colour: CGColor(gray: 0.55, alpha: 1)))
            cursor.y -= 3
        }

        func carryLine(_ label: String) {
            cursor.add(.text(label, x: style.margin, y: cursor.y, size: style.body,
                             bold: true, colour: CGColor(gray: 0.35, alpha: 1),
                             width: style.textWidth * 0.5, trailing: false))
            cursor.add(.text(money(carried, source: source), x: style.margin, y: cursor.y,
                             size: style.body, bold: true,
                             colour: CGColor(gray: 0.35, alpha: 1),
                             width: style.textWidth, trailing: true))
            cursor.y -= style.leading
        }

        _ = cursor.need(style.leading * 3)
        heads()

        for (index, record) in rows.enumerated() {
            if cursor.y - style.leading < cursor.bottom {
                // The break: the running total goes at the foot of this page
                // and again at the head of the next. A page-two column of
                // figures with no heading is a page nobody can read.
                carryLine("Carried forward")
                _ = cursor.need(.greatestFiniteMagnitude)
                heads()
                carryLine("Carried forward")
            }
            if zebra, index % 2 == 1 {
                cursor.add(.fill(CGRect(x: style.margin, y: cursor.y - style.body * 0.25,
                                        width: style.textWidth, height: style.leading),
                                 CGColor(gray: 0.95, alpha: 1)))
            }
            var x = style.margin
            for (position, column) in columns.enumerated() {
                let raw = record[column.column] ?? ""
                let shown = spelledCell(raw, table: block.table, column: column.column,
                                        source: source)
                let cell = slot(position, from: x)
                cursor.add(.text(shown, x: cell.x, y: cursor.y, size: style.body, bold: false,
                                 colour: CGColor(gray: 0.1, alpha: 1),
                                 width: cell.width, trailing: column.isTrailing))
                x += widths[position]
            }
            if let carryIndex {
                carried += Formula.number(record[columns[carryIndex].column] ?? "")
            }
            cursor.y -= style.leading
            if ruled {
                cursor.add(.rule(y: cursor.y + style.leading * 0.18, from: style.margin,
                                 to: pageWidth - style.margin, thickness: 0.4,
                                 colour: CGColor(gray: 0.85, alpha: 1)))
            }
        }
        cursor.y -= style.gap * 0.5
    }

    // MARK: - Sums and tax

    static func tally(_ block: PaperBlock, scopes: [PaperScopeRow],
                              source: PaperSource, paper: NeuridionPaper) -> String {
        let rows = source.rows(block.table.isEmpty ? paper.tableName : block.table,
                               block.keyColumn, raw(block.keyValue, scopes, source))
        let numbers = rows.map { Formula.number($0[block.column] ?? "") }
        let answer: Double
        switch block.tally {
        case .total: answer = numbers.reduce(0, +)
        case .count: answer = Double(rows.count)
        case .average: answer = numbers.isEmpty ? 0 : numbers.reduce(0, +) / Double(numbers.count)
        case .smallest: answer = numbers.min() ?? 0
        case .largest: answer = numbers.max() ?? 0
        }
        // Nothing to add up is nought, never empty: a bill with no lines is
        // nought euros, and a sheet saying "—" where a total belongs is a
        // sheet somebody has to interpret.
        return block.tally == .count ? String(Int(answer)) : money(answer, source: source)
    }

    /// Net, one line per rate, and the total. **Mixed rates on one invoice are
    /// the reason this block exists** — 7 % on food and 19 % on tools cannot be
    /// expressed with sum blocks at all.
    static func taxLines(_ block: PaperBlock, scopes: [PaperScopeRow],
                                 source: PaperSource,
                                 paper: NeuridionPaper) -> [(String, String)] {
        let rows = source.rows(block.table.isEmpty ? paper.tableName : block.table,
                               block.keyColumn, raw(block.keyValue, scopes, source))
        var perRate: [Double: Double] = [:]
        var net = 0.0
        for row in rows {
            let amount = Formula.number(row[block.column] ?? "")
            let rate = Formula.number(row[block.rateColumn] ?? "")
            net += amount
            perRate[rate, default: 0] += amount
        }
        var lines: [(String, String)] = [(block.label.isEmpty ? "Net" : block.label,
                                         money(net, source: source))]
        var tax = 0.0
        for rate in perRate.keys.sorted() {
            let base = perRate[rate] ?? 0
            let share = base * rate / 100
            tax += share
            lines.append(("\(Formula.text(rate)) % of \(money(base, source: source))",
                          money(share, source: source)))
        }
        // **And the gross.** Without it the block stops one line short of the
        // number the whole sheet exists for — the first invoice printed with it
        // showed a "Total" equal to the net, which is a wrong invoice
        // that looks like a right one. A breakdown that does not end in what is
        // owed is half a breakdown.
        lines.append(("Total", money(net + tax, source: source)))
        return lines
    }

    // MARK: - Chips

    /// A line of runs, with the chips filled in.
    ///
    /// The order is the record on top of the scope stack first, then the ones
    /// under it, then whatever the caller can look up — a control of the
    /// screen, a variable, a setting. That is the order `Formula` already
    /// uses, and it is why `{Job.Titel}` inside a repetition means the job.
    ///
    /// **An empty value stays empty and the line stays put.** Olly's call: no
    /// line disappears because a chip found nothing, not even in an address.
    static func spell(_ runs: [PaperRun], _ scopes: [PaperScopeRow],
                      _ source: PaperSource) -> String {
        runs.map { run in
            guard run.isChip else { return run.text }
            guard let found = value(of: run.text, scopes, source) else { return "" }
            // **A column is spelled the same wherever it is drawn.** The first
            // invoice off this painter printed the same date as "10.10.2024"
            // inside the table and "2024-10-10" in the value line above it,
            // because only the table knew which column it was drawing. The
            // scope carries its table now, so both go through `ColumnValue`.
            guard let table = found.table else { return found.text }
            return spelledCell(found.text, table: table, column: found.column, source: source)
        }.joined()
    }

    /// The same lookup, **unspelled** — for a value that is compared rather
    /// than read: a repetition's key, a picture's file name, the content of a
    /// QR code.
    static func raw(_ runs: [PaperRun], _ scopes: [PaperScopeRow],
                    _ source: PaperSource) -> String {
        runs.map { run in
            guard run.isChip else { return run.text }
            return value(of: run.text, scopes, source)?.text ?? ""
        }.joined()
    }

    /// What a chip found: the text, and — when it came out of a record — the
    /// table and column it came from, so it can be spelled.
    private struct Found {
        var text: String
        var table: String?
        var column: String = ""
    }

    private static func value(of name: String, _ scopes: [PaperScopeRow],
                              _ source: PaperSource) -> Found? {
        if name.hasPrefix("@") { return sheetValue(name).map { Found(text: $0, table: nil) } }
        // `Rechnung.Nummer` and `Nummer` both find the column; the table part
        // is what a person reads, not a second lookup.
        let column = name.contains(".") ? String(name.split(separator: ".").last!) : name
        for scope in scopes.reversed() {
            if let found = scope.row[column] {
                return Found(text: found, table: scope.table, column: column)
            }
            if let found = scope.row[name] {
                return Found(text: found, table: scope.table, column: name)
            }
        }
        guard let outside = source.lookup(name) ?? source.lookup(column) else { return nil }
        // A control, a variable or a setting: it is already in the spelling
        // the app shows it in, and there is no column to look a type up with.
        return Found(text: outside, table: nil)
    }

    /// What the sheet knows about itself.
    ///
    /// The German spellings are still understood. They were the first names
    /// these chips had, and a sheet saved with `@Heute` in it must go on
    /// printing the date — a renamed placeholder that silently prints nothing
    /// is the worst kind of rename there is.
    private static func sheetValue(_ name: String) -> String? {
        switch name {
        case "@Today", "@Heute":
            let formatter = DateFormatter()
            formatter.dateStyle = .long
            formatter.timeStyle = .none
            return formatter.string(from: Date())
        // The ones only the second pass knows. They are left as their own
        // name here and replaced when the pages are counted.
        case "@Page", "@Pages", "@Seite", "@Seiten": return name
        default: return nil
        }
    }

    // MARK: - Spellings

    private static func spelledCell(_ raw: String, table: String, column: String,
                                    source: PaperSource) -> String {
        guard let type = source.type(table, column) else { return raw }
        return ColumnValue.display(raw, as: type, currency: source.currency)
    }

    private static func money(_ value: Double, source: PaperSource) -> String {
        ColumnValue.display(Formula.text(value), as: .amount, currency: source.currency)
    }

    // MARK: - Pictures

    private static func placeImage(_ block: PaperBlock, _ cursor: inout Cursor,
                                   _ scopes: [PaperScopeRow], source: PaperSource,
                                   style: Style) {
        let width = CGFloat(max(0.05, min(1, block.imageWidth))) * style.textWidth
        let height = width * 0.62
        _ = cursor.need(height + style.gap)
        let frame = CGRect(x: style.margin, y: cursor.y - height, width: width, height: height)

        if let image = picture(block, scopes, source: source, size: width) {
            cursor.add(.picture(image, frame))
        } else {
            // A picture that cannot be found leaves a **light grey box with
            // its name** — not nothing. The same rule that draws an unreadable
            // canvas row grey with its tag: a hole you can see is a hole
            // somebody fills.
            cursor.add(.missing(block.imageName.isEmpty ? block.source.rawValue
                                                        : block.imageName, frame))
        }
        cursor.y -= height + style.gap
    }

    private static func picture(_ block: PaperBlock, _ scopes: [PaperScopeRow],
                                source: PaperSource, size: CGFloat) -> CGImage? {
        switch block.source {
        case .qr:
            return code("CIQRCodeGenerator", raw(block.text, scopes, source), size: size)
        case .barcode:
            return code("CICode128BarcodeGenerator", raw(block.text, scopes, source), size: size)
        case .file:
            return loaded(block.imageName, source: source)
        case .cell:
            let name = value(of: block.imageName, scopes, source)?.text ?? ""
            return loaded(name, source: source)
        case .symbol:
            // An SF Symbol needs AppKit or UIKit to become a picture, and this
            // file may have neither — `Core/` is compiled into the player too.
            // So a symbol draws as its named box here and is filled in by the
            // host that has the frameworks.
            return nil
        }
    }

    private static func loaded(_ name: String, source: PaperSource) -> CGImage? {
        guard !name.isEmpty, let assets = source.assets else { return nil }
        let url = assets.appendingPathComponent(name)
        guard let provider = CGDataProvider(url: url as CFURL) else { return nil }
        if name.lowercased().hasSuffix(".png") {
            return CGImage(pngDataProviderSource: provider, decode: nil,
                           shouldInterpolate: true, intent: .defaultIntent)
        }
        return CGImage(jpegDataProviderSource: provider, decode: nil,
                       shouldInterpolate: true, intent: .defaultIntent)
    }

    /// QR and barcode from CoreImage — both in the system, no dependency
    /// bought. The interesting half, recognising one, has been proven since
    /// the camera existed; drawing one was never done, and it is three lines.
    private static func code(_ filter: String, _ text: String, size: CGFloat) -> CGImage? {
        guard !text.isEmpty, let generator = CIFilter(name: filter) else { return nil }
        generator.setValue(Data(text.utf8), forKey: "inputMessage")
        guard let output = generator.outputImage else { return nil }
        let scale = max(1, size / max(output.extent.width, 1))
        let scaled = output.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
        return CIContext().createCGImage(scaled, from: scaled.extent)
    }

    // MARK: - Wrapping

    private static func wrap(_ text: String, width: CGFloat, size: CGFloat,
                             style: Style) -> [String] {
        // Broken on words rather than through `CTTypesetter`, because the
        // layout pass needs *how many lines* before anything is drawn, and a
        // typesetter needs a context. The drawing pass measures again for the
        // one thing that matters there — cutting a cell that is too wide.
        let perLine = max(8, Int(width / (size * 0.5)))
        var lines: [String] = []
        for paragraph in text.replacingOccurrences(of: "\r\n", with: "\n")
            .components(separatedBy: "\n") {
            guard !paragraph.trimmingCharacters(in: .whitespaces).isEmpty else {
                lines.append("")
                continue
            }
            var line = ""
            for word in paragraph.split(separator: " ", omittingEmptySubsequences: false) {
                if line.isEmpty {
                    line = String(word)
                } else if line.count + 1 + word.count <= perLine {
                    line += " " + word
                } else {
                    lines.append(line)
                    line = String(word)
                }
            }
            if !line.isEmpty { lines.append(line) }
        }
        return lines
    }

    // MARK: - Pass two: drawing

    private static func draw(_ runs: [[Page]], paper: NeuridionPaper,
                             style: Style) -> Data? {
        let data = NSMutableData()
        guard let consumer = CGDataConsumer(data: data as CFMutableData) else { return nil }
        var box = CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight)
        guard let context = CGContext(consumer: consumer, mediaBox: &box, nil) else { return nil }

        for pages in runs {
            for (index, page) in pages.enumerated() {
                context.beginPDFPage(nil)
                if !paper.stamp.isEmpty { stamp(paper.stamp, style: style, in: context) }
                if paper.showsMarks { marks(in: context) }
                if paper.head.isOn {
                    band(paper.head, height: headHeight(paper, style), style: style,
                         in: context)
                }
                if paper.foot.isOn {
                    foot(paper.foot, page: index + 1, of: pages.count, style: style,
                         in: context)
                }
                for piece in page.pieces {
                    paint(piece, page: index + 1, of: pages.count, style: style, in: context)
                }
                context.endPDFPage()
            }
        }
        context.closePDF()
        return data as Data
    }

    private static func paint(_ piece: Piece, page: Int, of pages: Int, style: Style,
                              in context: CGContext) {
        switch piece {
        case .text(let text, let x, let y, let size, let bold, let colour, let width,
                   let trailing):
            // `@Page` and `@Pages` are the two a chip cannot answer on the
            // first pass, because the count is not known until the last page
            // has been laid out. They are filled in here, where it is.
            let filled = text
                .replacingOccurrences(of: "@Pages", with: String(pages))
                .replacingOccurrences(of: "@Page", with: String(page))
                .replacingOccurrences(of: "@Seiten", with: String(pages))
                .replacingOccurrences(of: "@Seite", with: String(page))
            write(filled, x: x, y: y, size: size, bold: bold, colour: colour,
                  width: width, trailing: trailing, serif: style.serif, in: context)

        case .rule(let y, let from, let to, let thickness, let colour):
            context.setStrokeColor(colour)
            context.setLineWidth(thickness)
            context.move(to: CGPoint(x: from, y: y))
            context.addLine(to: CGPoint(x: to, y: y))
            context.strokePath()

        case .fill(let rect, let colour):
            context.setFillColor(colour)
            context.fill(rect)

        case .picture(let image, let rect):
            context.draw(image, in: rect)

        case .missing(let name, let rect):
            context.setFillColor(CGColor(gray: 0.93, alpha: 1))
            context.fill(rect)
            write(name, x: rect.minX + 4, y: rect.midY + 4, size: style.small, bold: false,
                  colour: CGColor(gray: 0.5, alpha: 1), width: rect.width - 8,
                  trailing: false, serif: style.serif, in: context)

        case .pageNumber(let x, let y, let size, let colour):
            write("Page \(page) of \(pages)", x: x, y: y, size: size, bold: false,
                  colour: colour, width: 200, trailing: true, serif: style.serif,
                  in: context)
        }
    }

    /// One run of text. Trailing means the run ends at `x + width` rather than
    /// starting at `x` — two alignments cover every line on a sheet, and a
    /// third is a setting nobody sets on purpose.
    private static func write(_ text: String, x: CGFloat, y: CGFloat, size: CGFloat,
                              bold: Bool, colour: CGColor, width: CGFloat,
                              trailing: Bool, serif: Bool, in context: CGContext) {
        guard !text.isEmpty else { return }
        let font = self.font(size: size, bold: bold, serif: serif)
        let attributes: [CFString: Any] = [
            kCTFontAttributeName: font,
            kCTForegroundColorAttributeName: colour
        ]
        guard let attributed = CFAttributedStringCreate(nil, text as CFString,
                                                        attributes as CFDictionary) else { return }
        var line = CTLineCreateWithAttributedString(attributed)
        let measured = CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil))
        if measured > width {
            let dots = CFAttributedStringCreate(nil, "…" as CFString, attributes as CFDictionary)
                .map { CTLineCreateWithAttributedString($0) }
            line = CTLineCreateTruncatedLine(line, Double(width), .end, dots) ?? line
        }
        let drawn = CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil))
        context.textPosition = CGPoint(x: trailing ? x + width - drawn : x, y: y - size)
        CTLineDraw(line, context)
    }

    private static func font(size: CGFloat, bold: Bool, serif: Bool) -> CTFont {
        // Two typefaces and no menu: the system face for forms, an antiqua for
        // letters. Both are there, both are good, both print the same
        // everywhere.
        let name: String
        switch (serif, bold) {
        case (true, true): name = "Times-Bold"
        case (true, false): name = "Times-Roman"
        case (false, true): name = "Helvetica-Bold"
        case (false, false): name = "Helvetica"
        }
        return CTFontCreateWithName(name as CFString, size, nil)
    }

    // MARK: - The bands

    private static func band(_ head: PaperHead, height: CGFloat, style: Style,
                             in context: CGContext) {
        let top = pageHeight - style.margin
        write(head.name, x: style.margin, y: top, size: style.body * 1.25, bold: true,
              colour: CGColor(gray: 0.1, alpha: 1), width: style.textWidth * 0.55,
              trailing: false, serif: style.serif, in: context)
        write(head.line, x: style.margin, y: top - style.body * 1.5, size: style.small,
              bold: false, colour: CGColor(gray: 0.5, alpha: 1),
              width: style.textWidth * 0.55, trailing: false, serif: style.serif,
              in: context)
        var y = top
        for line in head.contact.prefix(5) {
            write(line, x: style.margin, y: y, size: style.small, bold: false,
                  colour: CGColor(gray: 0.5, alpha: 1), width: style.textWidth,
                  trailing: true, serif: style.serif, in: context)
            y -= style.small * 1.4
        }
        if style.look.showsAccentBar {
            context.setFillColor(style.accent)
            // Under the whole band, not at a fixed offset from the top — the
            // band is as tall as its contents, and a bar at a guessed height
            // is a bar drawn through the telephone number.
            context.fill(CGRect(x: style.margin, y: top - height + style.gap,
                                width: style.textWidth, height: 2.5))
        }
    }

    private static func foot(_ foot: PaperFoot, page: Int, of pages: Int, style: Style,
                             in context: CGContext) {
        let base = style.margin + style.small * 4.4
        context.setStrokeColor(CGColor(gray: 0.8, alpha: 1))
        context.setLineWidth(0.5)
        context.move(to: CGPoint(x: style.margin, y: base + style.small))
        context.addLine(to: CGPoint(x: pageWidth - style.margin, y: base + style.small))
        context.strokePath()

        let columns = Array(foot.columns.prefix(3))
        let width = (style.textWidth * 0.78) / CGFloat(max(1, columns.count))
        for (index, column) in columns.enumerated() {
            var y = base
            for line in column.prefix(4) {
                write(line, x: style.margin + CGFloat(index) * width, y: y,
                      size: style.small, bold: false, colour: CGColor(gray: 0.5, alpha: 1),
                      width: width - 6, trailing: false, serif: style.serif, in: context)
                y -= style.small * 1.35
            }
        }
        if foot.showsPageNumber {
            write("Page \(page) of \(pages)", x: style.margin, y: base,
                  size: style.small, bold: false, colour: CGColor(gray: 0.5, alpha: 1),
                  width: style.textWidth, trailing: true, serif: style.serif, in: context)
        }
    }

    // MARK: - Stamp and marks

    /// Diagonal, pale, behind the text. A draft quote that is not recognisable
    /// as a draft gets paid.
    private static func stamp(_ text: String, style: Style, in context: CGContext) {
        context.saveGState()
        context.translateBy(x: pageWidth / 2, y: pageHeight / 2)
        context.rotate(by: .pi / 6)
        write(text, x: -220, y: 0, size: 64, bold: true,
              colour: CGColor(red: 0.85, green: 0.2, blue: 0.2, alpha: 0.12),
              width: 440, trailing: false, serif: style.serif, in: context)
        context.restoreGState()
    }

    /// Two hairlines and a punch mark. Five lines of code, and without them
    /// nobody folds a letter straight.
    private static func marks(in context: CGContext) {
        context.setStrokeColor(CGColor(gray: 0.75, alpha: 1))
        context.setLineWidth(0.4)
        // 105 mm and 210 mm from the top, in points, measured from the bottom.
        for millimetres in [105.0, 210.0] {
            let y = pageHeight - CGFloat(millimetres) * 72.0 / 25.4
            context.move(to: CGPoint(x: 0, y: y))
            context.addLine(to: CGPoint(x: 12, y: y))
        }
        let punch = pageHeight / 2
        context.move(to: CGPoint(x: 0, y: punch))
        context.addLine(to: CGPoint(x: 16, y: punch))
        context.strokePath()
    }
}
